import { logger } from 'storybook/internal/client-logger';
import {
  CURRENT_STORY_WAS_SET,
  DOCS_PREPARED,
  GLOBALS_UPDATED,
  PRELOAD_ENTRIES,
  PREVIEW_KEYDOWN,
  SET_CURRENT_STORY,
  STORY_CHANGED,
  STORY_ERRORED,
  STORY_MISSING,
  STORY_PREPARED,
  STORY_RENDER_PHASE_CHANGED,
  STORY_SPECIFIED,
  STORY_THREW_EXCEPTION,
  STORY_UNCHANGED,
  UPDATE_QUERY_PARAMS,
} from 'storybook/internal/core-events';
import {
  CalledPreviewMethodBeforeInitializationError,
  EmptyIndexError,
  MdxFileWithNoCsfReferencesError,
  NoStoryMatchError,
} from 'storybook/internal/preview-errors';
import type { DocsIndexEntry, StoryIndex } from 'storybook/internal/types';
import type { Args, Globals, Renderer, StoryId, ViewMode } from 'storybook/internal/types';
import type { ModuleImportFn, ProjectAnnotations } from 'storybook/internal/types';

import invariant from 'tiny-invariant';

import type { StorySpecifier } from '../store/StoryIndexStore';
import type { MaybePromise } from './Preview';
import { Preview } from './Preview';
import type { Selection, SelectionStore } from './SelectionStore';
import type { View } from './View';
import { CsfDocsRender } from './render/CsfDocsRender';
import { MdxDocsRender } from './render/MdxDocsRender';
import { PREPARE_ABORTED } from './render/Render';
import { StoryRender } from './render/StoryRender';

const globalWindow = globalThis;

function focusInInput(event: Event) {
  const target = ((event.composedPath && event.composedPath()[0]) || event.target) as Element;
  return /input|textarea/i.test(target.tagName) || target.getAttribute('contenteditable') !== null;
}

export const AUTODOCS_TAG = 'autodocs';
export const ATTACHED_MDX_TAG = 'attached-mdx';
export const UNATTACHED_MDX_TAG = 'unattached-mdx';

/** Was this docs entry generated by a .mdx file? (see discussion below) */
export function isMdxEntry({ tags }: DocsIndexEntry) {
  return tags?.includes(UNATTACHED_MDX_TAG) || tags?.includes(ATTACHED_MDX_TAG);
}

type PossibleRender<TRenderer extends Renderer> =
  | StoryRender<TRenderer>
  | CsfDocsRender<TRenderer>
  | MdxDocsRender<TRenderer>;

function isStoryRender<TRenderer extends Renderer>(
  r: PossibleRender<TRenderer>
): r is StoryRender<TRenderer> {
  return r.type === 'story';
}

function isDocsRender<TRenderer extends Renderer>(
  r: PossibleRender<TRenderer>
): r is CsfDocsRender<TRenderer> | MdxDocsRender<TRenderer> {
  return r.type === 'docs';
}

function isCsfDocsRender<TRenderer extends Renderer>(
  r: PossibleRender<TRenderer>
): r is CsfDocsRender<TRenderer> {
  return isDocsRender(r) && r.subtype === 'csf';
}

export class PreviewWithSelection<TRenderer extends Renderer> extends Preview<TRenderer> {
  currentSelection?: Selection;

  currentRender?: PossibleRender<TRenderer>;

  constructor(
    public importFn: ModuleImportFn,

    public getProjectAnnotations: () => MaybePromise<ProjectAnnotations<TRenderer>>,

    public selectionStore: SelectionStore,

    public view: View<TRenderer['canvasElement']>
  ) {
    // We need to call initialize ourself (i.e. stop super() from doing it, with false)
    // because otherwise this.view will not get set in time.
    super(importFn, getProjectAnnotations, undefined, false);
    this.initialize();
  }

  setupListeners() {
    super.setupListeners();

    globalWindow.onkeydown = this.onKeydown.bind(this);

    this.channel.on(SET_CURRENT_STORY, this.onSetCurrentStory.bind(this));
    this.channel.on(UPDATE_QUERY_PARAMS, this.onUpdateQueryParams.bind(this));
    this.channel.on(PRELOAD_ENTRIES, this.onPreloadStories.bind(this));
  }

  async setInitialGlobals() {
    if (!this.storyStoreValue) {
      throw new CalledPreviewMethodBeforeInitializationError({ methodName: 'setInitialGlobals' });
    }

    const { globals } = this.selectionStore.selectionSpecifier || {};
    if (globals) {
      this.storyStoreValue.userGlobals.updateFromPersisted(globals);
    }
    this.emitGlobals();
  }

  // If initialization gets as far as the story index, this function runs.
  async initializeWithStoryIndex(storyIndex: StoryIndex): Promise<void> {
    await super.initializeWithStoryIndex(storyIndex);

    return this.selectSpecifiedStory();
  }

  // Use the selection specifier to choose a story, then render it
  async selectSpecifiedStory() {
    if (!this.storyStoreValue) {
      throw new CalledPreviewMethodBeforeInitializationError({
        methodName: 'selectSpecifiedStory',
      });
    }

    // If the story has been selected during initialization - if `SET_CURRENT_STORY` is
    // emitted while we are loading the preview, we don't need to do any selection now.

    // If the story has been selected during initialization - if `SET_CURRENT_STORY` is
    // emitted while we are loading the preview, we don't need to do any selection now.
    if (this.selectionStore.selection) {
      await this.renderSelection();
      return;
    }

    if (!this.selectionStore.selectionSpecifier) {
      this.renderMissingStory();
      return;
    }

    const { storySpecifier, args } = this.selectionStore.selectionSpecifier;
    const entry = this.storyStoreValue.storyIndex.entryFromSpecifier(storySpecifier);

    if (!entry) {
      if (storySpecifier === '*') {
        this.renderStoryLoadingException(storySpecifier, new EmptyIndexError());
      } else {
        this.renderStoryLoadingException(
          storySpecifier,
          new NoStoryMatchError({ storySpecifier: storySpecifier.toString() })
        );
      }

      return;
    }

    const { id: storyId, type: viewMode } = entry;
    this.selectionStore.setSelection({ storyId, viewMode });

    this.channel.emit(STORY_SPECIFIED, this.selectionStore.selection);
    this.channel.emit(CURRENT_STORY_WAS_SET, this.selectionStore.selection);

    await this.renderSelection({ persistedArgs: args });
  }

  // EVENT HANDLERS

  // This happens when a config file gets reloaded
  async onGetProjectAnnotationsChanged({
    getProjectAnnotations,
  }: {
    getProjectAnnotations: () => MaybePromise<ProjectAnnotations<TRenderer>>;
  }) {
    await super.onGetProjectAnnotationsChanged({ getProjectAnnotations });

    if (this.selectionStore.selection) {
      this.renderSelection();
    }
  }

  // This happens when a glob gets HMR-ed
  async onStoriesChanged({
    importFn,
    storyIndex,
  }: {
    importFn?: ModuleImportFn;
    storyIndex?: StoryIndex;
  }) {
    await super.onStoriesChanged({ importFn, storyIndex });

    if (this.selectionStore.selection) {
      await this.renderSelection();
    } else {
      // Our selection has never applied before, but maybe it does now, let's try!
      await this.selectSpecifiedStory();
    }
  }

  onKeydown(event: KeyboardEvent) {
    if (!this.storyRenders.find((r) => r.disableKeyListeners) && !focusInInput(event)) {
      // We have to pick off the keys of the event that we need on the other side
      const { altKey, ctrlKey, metaKey, shiftKey, key, code, keyCode } = event;
      this.channel.emit(PREVIEW_KEYDOWN, {
        event: { altKey, ctrlKey, metaKey, shiftKey, key, code, keyCode },
      });
    }
  }

  async onSetCurrentStory(selection: { storyId: StoryId; viewMode?: ViewMode }) {
    /**
     * At the end of the initialization promise we will read the current story from the selection
     * store, so make sure we've updated it with the new selection or we'll lose track of it at the
     * end of init.
     */
    this.selectionStore.setSelection({ viewMode: 'story', ...selection });

    await this.storeInitializationPromise;

    this.channel.emit(CURRENT_STORY_WAS_SET, this.selectionStore.selection);
    this.renderSelection();
  }

  onUpdateQueryParams(queryParams: any) {
    this.selectionStore.setQueryParams(queryParams);
  }

  async onUpdateGlobals({ globals }: { globals: Globals }) {
    const currentStory =
      (this.currentRender instanceof StoryRender && this.currentRender.story) || undefined;
    super.onUpdateGlobals({ globals, currentStory });
    if (
      this.currentRender instanceof MdxDocsRender ||
      this.currentRender instanceof CsfDocsRender
    ) {
      await this.currentRender.rerender?.();
    }
  }

  async onUpdateArgs({ storyId, updatedArgs }: { storyId: StoryId; updatedArgs: Args }) {
    super.onUpdateArgs({ storyId, updatedArgs });
  }

  async onPreloadStories({ ids }: { ids: string[] }) {
    await this.storeInitializationPromise;

    if (this.storyStoreValue) {
      /**
       * It's possible that we're trying to preload a story in a ref we haven't loaded the iframe
       * for yet. Because of the way the targeting works, if we can't find the targeted iframe,
       * we'll use the currently active iframe which can cause the event to be targeted to the wrong
       * iframe, causing an error if the storyId does not exists there.
       */
      await Promise.allSettled(ids.map((id) => this.storyStoreValue?.loadEntry(id)));
    }
  }

  // RENDERING

  // We can either have:
  // - a story selected in "story" viewMode,
  //     in which case we render it to the root element, OR
  // - a story selected in "docs" viewMode,
  //     in which case we render the docsPage for that story
  protected async renderSelection({ persistedArgs }: { persistedArgs?: Args } = {}) {
    const { renderToCanvas } = this;

    if (!this.storyStoreValue || !renderToCanvas) {
      throw new CalledPreviewMethodBeforeInitializationError({ methodName: 'renderSelection' });
    }

    const { selection } = this.selectionStore;

    if (!selection) {
      throw new Error('Cannot call renderSelection as no selection was made');
    }

    const { storyId } = selection;
    let entry;
    try {
      entry = await this.storyStoreValue.storyIdToEntry(storyId);
    } catch (err) {
      if (this.currentRender) {
        await this.teardownRender(this.currentRender);
      }
      this.renderStoryLoadingException(storyId, err as Error);
      return;
    }

    const storyIdChanged = this.currentSelection?.storyId !== storyId;
    const viewModeChanged = this.currentRender?.type !== entry.type;

    // Show a spinner while we load the next story
    if (entry.type === 'story') {
      this.view.showPreparingStory({ immediate: viewModeChanged });
    } else {
      this.view.showPreparingDocs({ immediate: viewModeChanged });
    }

    // If the last render is still preparing, let's drop it right now. Either
    //   (a) it is a different story, which means we would drop it later, OR
    //   (b) it is the *same* story, in which case we will resolve our own .prepare() at the
    //       same moment anyway, and we should just "take over" the rendering.
    // (We can't tell which it is yet, because it is possible that an HMR is going on and
    //  even though the storyId is the same, the story itself is not).
    if (this.currentRender?.isPreparing()) {
      await this.teardownRender(this.currentRender);
    }

    let render: PossibleRender<TRenderer>;
    if (entry.type === 'story') {
      render = new StoryRender<TRenderer>(
        this.channel,
        this.storyStoreValue,
        renderToCanvas,
        this.mainStoryCallbacks(storyId),
        storyId,
        'story'
      );
    } else if (isMdxEntry(entry)) {
      render = new MdxDocsRender<TRenderer>(
        this.channel,
        this.storyStoreValue,
        entry,
        this.mainStoryCallbacks(storyId)
      );
    } else {
      render = new CsfDocsRender<TRenderer>(
        this.channel,
        this.storyStoreValue,
        entry,
        this.mainStoryCallbacks(storyId)
      );
    }

    // We need to store this right away, so if the story changes during
    // the async `.prepare()` below, we can (potentially) cancel it
    const lastSelection = this.currentSelection;
    this.currentSelection = selection;

    const lastRender = this.currentRender;
    this.currentRender = render;

    try {
      await render.prepare();
    } catch (err) {
      if (lastRender) {
        await this.teardownRender(lastRender);
      }

      if (err !== PREPARE_ABORTED) {
        this.renderStoryLoadingException(storyId, err as Error);
      }
      return;
    }

    const implementationChanged = !storyIdChanged && lastRender && !render.isEqual(lastRender);

    if (persistedArgs && isStoryRender(render)) {
      invariant(!!render.story);
      this.storyStoreValue.args.updateFromPersisted(render.story, persistedArgs);
    }

    // Don't re-render the story if nothing has changed to justify it
    if (
      lastRender &&
      !lastRender.torndown &&
      !storyIdChanged &&
      !implementationChanged &&
      !viewModeChanged
    ) {
      this.currentRender = lastRender;
      this.channel.emit(STORY_UNCHANGED, storyId);
      this.view.showMain();
      return;
    }

    // Wait for the previous render to leave the page. NOTE: this will wait to ensure anything async
    // is properly aborted, which (in some cases) can lead to the whole screen being refreshed.

    // Wait for the previous render to leave the page. NOTE: this will wait to ensure anything async
    // is properly aborted, which (in some cases) can lead to the whole screen being refreshed.
    if (lastRender) {
      await this.teardownRender(lastRender, { viewModeChanged });
    }

    // If we are rendering something new (as opposed to re-rendering the same or first story), emit

    // If we are rendering something new (as opposed to re-rendering the same or first story), emit
    if (lastSelection && (storyIdChanged || viewModeChanged)) {
      this.channel.emit(STORY_CHANGED, storyId);
    }

    if (isStoryRender(render)) {
      invariant(!!render.story);
      const {
        parameters,
        initialArgs,
        argTypes,
        unmappedArgs,
        initialGlobals,
        userGlobals,
        storyGlobals,
        globals,
      } = this.storyStoreValue.getStoryContext(render.story);

      this.channel.emit(STORY_PREPARED, {
        id: storyId,
        parameters,
        initialArgs,
        argTypes,
        args: unmappedArgs,
      });
      // We need to update globals whenever we go in or out of an overridden story.
      // As an optimization we could check if that's the case, but it seems complex and error-prone
      this.channel.emit(GLOBALS_UPDATED, { userGlobals, storyGlobals, globals, initialGlobals });
    } else {
      // Default to the project parameters for MDX docs
      let { parameters } = this.storyStoreValue.projectAnnotations;

      // We need to update globals whenever we go in or out of an overridden story.
      // As an optimization we could check if that's the case, but it seems complex and error-prone
      const { initialGlobals, globals } = this.storyStoreValue.userGlobals;
      this.channel.emit(GLOBALS_UPDATED, {
        globals,
        initialGlobals,
        storyGlobals: {},
        userGlobals: globals,
      });

      if (isCsfDocsRender(render) || render.entry.tags?.includes(ATTACHED_MDX_TAG)) {
        if (!render.csfFiles) {
          throw new MdxFileWithNoCsfReferencesError({ storyId });
        }
        ({ parameters } = this.storyStoreValue.preparedMetaFromCSFFile({
          csfFile: render.csfFiles[0],
        }));
      }

      this.channel.emit(DOCS_PREPARED, {
        id: storyId,
        parameters,
      });
    }

    if (isStoryRender(render)) {
      invariant(!!render.story);
      this.storyRenders.push(render as StoryRender<TRenderer>);
      (this.currentRender as StoryRender<TRenderer>).renderToElement(
        this.view.prepareForStory(render.story)
      );
    } else {
      this.currentRender.renderToElement(
        this.view.prepareForDocs(),
        // This argument is used for docs, which is currently only compatible with HTMLElements
        this.renderStoryToElement.bind(this) as any
      );
    }
  }

  async teardownRender(
    render: PossibleRender<TRenderer>,
    { viewModeChanged = false }: { viewModeChanged?: boolean } = {}
  ) {
    this.storyRenders = this.storyRenders.filter((r) => r !== render);
    await render?.teardown?.({ viewModeChanged });
  }

  // UTILITIES
  mainStoryCallbacks(storyId: StoryId) {
    return {
      showStoryDuringRender: () => this.view.showStoryDuringRender(),
      showMain: () => this.view.showMain(),
      showError: (err: { title: string; description: string }) => this.renderError(storyId, err),
      showException: (err: Error) => this.renderException(storyId, err),
    };
  }

  renderPreviewEntryError(reason: string, err: Error) {
    super.renderPreviewEntryError(reason, err);
    this.view.showErrorDisplay(err);
  }

  renderMissingStory() {
    this.view.showNoPreview();
    this.channel.emit(STORY_MISSING);
  }

  renderStoryLoadingException(storySpecifier: StorySpecifier, err: Error) {
    // logger.error(`Unable to load story '${storySpecifier}':`);
    logger.error(err);
    this.view.showErrorDisplay(err);
    this.channel.emit(STORY_MISSING, storySpecifier);
  }

  // renderException is used if we fail to render the story and it is uncaught by the app layer
  renderException(storyId: StoryId, error: Error) {
    const { name = 'Error', message = String(error), stack } = error;
    const renderId = this.currentRender?.renderId;
    this.channel.emit(STORY_THREW_EXCEPTION, { name, message, stack });
    this.channel.emit(STORY_RENDER_PHASE_CHANGED, { newPhase: 'errored', renderId, storyId });

    this.view.showErrorDisplay(error);
    logger.error(`Error rendering story '${storyId}':`);
    logger.error(error);
  }

  // renderError is used by the various app layers to inform the user they have done something
  // wrong -- for instance returned the wrong thing from a story
  renderError(storyId: StoryId, { title, description }: { title: string; description: string }) {
    const renderId = this.currentRender?.renderId;
    this.channel.emit(STORY_ERRORED, { title, description });
    this.channel.emit(STORY_RENDER_PHASE_CHANGED, { newPhase: 'errored', renderId, storyId });

    this.view.showErrorDisplay({ message: title, stack: description });
    logger.error(`Error rendering story ${title}: ${description}`);
  }
}
